Adding Vidigi to a Simple simpy Model (HSMA Structure) - vidigi 0.0.5 and above

On the Health Service Modelling Associates (HSMA) course we teach a particular way of writing your simpy models. More details of the approach we take can be found in our Little Book of DES.

However, the core concepts of adding vidigi to your models will be the same across different models - so this example will hopefully be helpful regardless of the way you structure your simpy models.

Note

ciw is quite different - we will not be able to add logging steps in the way we do in this simpy model.

However, in the utils module, the event_log_from_ciw_recs function provides a simple way to get the required logs out of your ciw model without any additional logging being added in manually.

Note

This model has been adapted from Monks, released under the MIT Licence

Vidigi’s requirements

The key input vidigi requires an event log of the times that each entity in your system reached key milestones like arriving in the system, beginning to queue for a resource, being seen by a resource, and exiting the system.

We also need to tell vidigi what kind of activity is happening at each point:

  • arrive/depart
  • queue
  • resource_use

We also provide vidigi with a table of coordinates that will help it to lay out our entities and resources, and determine their path from the entrance, to the exit, and to some extent their movement between stages.

Vidigi then takes this event log and the layout table and will process them into a table that tracks the position of every entity in the system at specified time intervals.

HSMA Model Structure

In HSMA, we use four primary classes to structure our models:

  • g, which stores model parameters (like the number of resources of a given type and distribution parameters) and simulation parameters (like the number of replications to run and the )
  • Entity, which may be named something more descriptive like ‘Patient’ or ‘Customer’. You may also have more than one entity class. Each entity will store information such as its ID, and will be passed into the model to work through the pathway.
  • Model, which will generate entities, simulate the pathway the entity takes through the system, and contain a way to run a single replication of the model
  • Trial, which allows us to run the simulation multiple times, collect results from all of these, and get an indication of average performance and performance variation across our different model runs

A Simple Model

We’re going to start off with a very simple model of a walk-in clinic pathway.

In this clinic, patients arrive and are seen in the order they arrive by one of several available nurses. All nurses have the same skillset, so the queue is a simple first-in-first-out (FIFO). There is some variability in the arrival time of patients, as well as variability in how long it takes for each patient to be seen.

the g Class

In our g class, we set up parameters that will be used throughout.

class g:
    random_number_set = 42 # Control
    ial seeds of each stream of pseudorandom numbers used

    n_cubicles = 3 # The number of treatment cubicles
    trauma_treat_mean = 40 # Mean of the trauma cubicle treatment distribution (Lognormal)
    trauma_treat_var = 5 # Variance of the trauma cubicle treatment distribution (Lognormal)

    arrival_rate = 5 # mean of the exponential distribution for sampling the inter-arrival time of entities


    sim_duration = 600 # The number of time units the simulation will run for
    number_of_runs = 100 # The number of times the simulation will be run with different random number streams

the Patient Class

Our Patient class represents a single individual.

The attributes in this class are used to track various metrics that will be used for determining how well our particular scenario has performed - think of it like a person holding a clipboard that is having various times and figures recorded on it as they move through the system.

class Patient:
    def __init__(self, p_id):
        self.identifier = p_id
        self.arrival = -np.inf
        self.wait_treat = -np.inf
        self.total_time = -np.inf
        self.treat_duration = -np.inf

the Model Class

Our model class is more complex.

:::

the init method

First, we set up a series of attributes

    def __init__(self, run_number):
        # Create a SimPy environment in which everything will live
        self.env = simpy.Environment()

        # Create a patient counter (which we'll use as a patient ID)
        self.patient_counter = 0

        self.patients = []

        # Create our resources
        self.init_resources()

        # Store the passed in run number
        self.run_number = run_number

        # Create a new Pandas DataFrame that will store some results against
        # the patient ID (which we'll use as the index).
        self.results_df = pd.DataFrame()
        self.results_df["Patient ID"] = [1]
        self.results_df["Queue Time Cubicle"] = [0.0]
        self.results_df["Time with Nurse"] = [0.0]
        self.results_df.set_index("Patient ID", inplace=True)

        # Create an attribute to store the mean queuing times across this run of
        # the model
        self.mean_q_time_cubicle = 0

        self.patient_inter_arrival_dist = Exponential(mean = g.arrival_rate,
                                                      random_seed = self.run_number*g.random_number_set)
        self.treat_dist = Lognormal(mean = g.trauma_treat_mean,
                                    stdev = g.trauma_treat_var,
                                    random_seed = self.run_number*g.random_number_set)

the init_resources method

    def init_resources(self):
        '''
        Init the number of resources

        Resource list:
            1. Nurses/treatment bays (same thing in this model)

        '''
        self.treatment_cubicles = simpy.Resource(self.env, capacity=g.n_cubicles)

the generator_patient_arrivals method

    def generator_patient_arrivals(self):
        # We use an infinite loop here to keep doing this indefinitely whilst
        # the simulation runs
        while True:
            # Increment the patient counter by 1 (this means our first patient
            # will have an ID of 1)
            self.patient_counter += 1

            # Create a new patient - an instance of the Patient Class we
            # defined above.  Remember, we pass in the ID when creating a
            # patient - so here we pass the patient counter to use as the ID.
            p = Patient(self.patient_counter)

            # Store patient in list for later easy access
            self.patients.append(p)

            # Tell SimPy to start up the attend_clinic generator function with
            # this patient (the generator function that will model the
            # patient's journey through the system)
            self.env.process(self.attend_clinic(p))

            # Randomly sample the time to the next patient arriving.  Here, we
            # sample from an exponential distribution (common for inter-arrival
            # times), and pass in a lambda value of 1 / mean.  The mean
            # inter-arrival time is stored in the g class.
            sampled_inter = self.patient_inter_arrival_dist.sample()

            # Freeze this instance of this function in place until the
            # inter-arrival time we sampled above has elapsed.  Note - time in
            # SimPy progresses in "Time Units", which can represent anything
            # you like (just make sure you're consistent within the model)
            yield self.env.timeout(sampled_inter)

the attend_clinic function

    def attend_clinic(self, patient):
        patient.arrival = self.env.now

        # request examination resource
        start_wait = self.env.now

        with self.treatment_cubicles.request() as req:
            # Seize a treatment resource when available
            yield req

            # record the waiting time for registration
            patient.wait_treat = self.env.now - start_wait

            # sample treatment duration
            patient.treat_duration = self.treat_dist.sample()
            yield self.env.timeout(patient.treat_duration)

        # total time in system
        patient.total_time = self.env.now - patient.arrival

the calculate_run_results function

    def calculate_run_results(self):
        # Take the mean of the queuing times across patients in this run of the
        # model.
        self.mean_q_time_cubicle = self.results_df["Queue Time Cubicle"].mean()

the run function

    def run(self):
        # Start up our DES entity generators that create new patients.  We've
        # only got one in this model, but we'd need to do this for each one if
        # we had multiple generators.
        self.env.process(self.generator_patient_arrivals())

        # Run the model for the duration specified in g class
        self.env.run(until=g.sim_duration)

        # Now the simulation run has finished, call the method that calculates
        # run results
        self.calculate_run_results()

the Trial Class

the init method

def  __init__(self):
    self.df_trial_results = pd.DataFrame()
    self.df_trial_results["Run Number"] = [0]
    self.df_trial_results["Arrivals"] = [0]
    self.df_trial_results["Mean Queue Time Cubicle"] = [0.0]
    self.df_trial_results.set_index("Run Number", inplace=True)

The run_trial method

Run the simulation for the number of runs specified in g class.

or each run, we create a new instance of the Model class and call its run method, which sets everything else in motion.

Once the run has completed, we grab out the stored run results (just mean queuing time here) and store it against the run number in the trial results dataframe.

    def run_trial(self):
        print(f"{g.n_cubicles} nurses")
        print("") ## Print a blank line

        for run in range(1, g.number_of_runs+1):
            random.seed(run)

            my_model = Model(run)
            my_model.run()

            self.df_trial_results.loc[run] = [my_model.mean_q_time_cubicle]

        return self.df_trial_results

Making Changes for Vidigi

imports

Original
import random
import numpy as np
import pandas as pd
import simpy
from sim_tools.distributions import Exponential, Lognormal
With Vidigi Modifications
import random
import numpy as np
import pandas as pd
import simpy
from sim_tools.distributions import Exponential, Lognormal
from vidigi.resources import VidigiStore 
from vidigi.logging import EventLogger 
from vidigi.animation import animate_activity_log 

the g Class

Our g class is unchanged.

the Entity Class

Our entity class - in this case, Patient - is unchanged.

the Model Class

The init method

To our init method for the Model class, we add an instance of the vidigi EventLogger class that will help us to generate our event logs.

We will also remove the various bits of code that will track different metrics - the vidigi EventLogger class can help us with that later on too, and it’s more efficient to calculate these things after running our simulation, and keeps this logic more separate from the simulation logic, making our code easier to modify and maintain.

Original
def __init__(self, run_number):
    # Create a SimPy environment in which everything will live
    self.env = simpy.Environment()

    # Create a patient counter (which we'll use as a patient ID)
    self.patient_counter = 0

    # Create an empty list to store patient objects in
    self.patients = []

    # Create our resources
    self.init_resources()

    # Store the passed in run number
    self.run_number = run_number

    # Create a new Pandas DataFrame that will store some results
    # against the patient ID (which we'll use as the index).
    self.results_df = pd.DataFrame()
    self.results_df["Patient ID"] = [1]
    self.results_df["Queue Time Cubicle"] = [0.0]
    self.results_df["Time with Nurse"] = [0.0]
    self.results_df.set_index("Patient ID", inplace=True)

    # Create an attribute to store the mean queuing times
    # across this run of the model
    self.mean_q_time_cubicle = 0

    self.patient_inter_arrival_dist = Exponential(
        mean = g.arrival_rate,
        random_seed = self.run_number*g.random_number_set
        )

    self.treat_dist = Lognormal(
        mean = g.trauma_treat_mean,
        stdev = g.trauma_treat_var,
        random_seed = self.run_number*g.random_number_set
        )
With Vidigi Modifications
def __init__(self, run_number):
    # Create a SimPy environment in which everything will live
    self.env = simpy.Environment()

    # Store the passed in run number
    self.run_number = run_number

    # By passing in the env we've created, the logger will default to the simulation  
    # time when populating the time column of our event logs  
    # Passing the run number also ensures we can separate out different runs  
    # of the simulation in our later calculations 
    self.logger = EventLogger( 
        env=self.env,  
        run_number=self.run_number 
        ) 

    # Create a patient counter (which we'll use as a patient ID)
    self.patient_counter = 0

    # Create an empty list to store patient objects in
    self.patients = []

    # Create our resources
    self.init_resources()

    self.patient_inter_arrival_dist = Exponential(
        mean = g.arrival_rate,
        random_seed = self.run_number*g.random_number_set
        )

    self.treat_dist = Lognormal(
        mean = g.trauma_treat_mean,
        stdev = g.trauma_treat_var,
        random_seed = self.run_number*g.random_number_set
        )

the init_resources method

Vidigi needs to know which resource a user made use of so that we can ensure it stays with the correct resource throughout its time in the animation.

The standard simpy Resource does not have a way of tracking that, so we need to use a special store type provided by Vidigi that allows us to track resource IDs - without having to change our code as much as we would with a standard Simpy store.

If you are using priority resources, this step will be a little different - see Example 3 in the documents if you need to use Resources that prioritise some entities over others.

Original
def init_resources(self):
    self.treatment_cubicles = simpy.Resource(
        self.env,
        capacity=g.n_cubicles
        )
With Vidigi Modifications
def init_resources(self):
    self.treatment_cubicles = VidigiStore( 
        self.env, 
        num_resources=g.n_cubicles 
        ) 

the generator_patient_arrivals method

This method is unchanged.

the attend_clinic method

This is the key place in which we add our logging. The logs are what vidigi relies on to calculate who should be where, when, within the animation.

This is also where we need to slightly change the way we request resources to allow us to access their ID attribute.

Where we would have previously used

with self.treatment_cubicles.request() as req:
    # Seize a treatment resource when available
    yield req

    # ALL CODE WHERE WE NEED TO KEEP HOLD OF THE RESOURCE

# CONTINUE AFTER RELEASING RESOURCE HERE

we instead now use

with self.treatment_cubicles.request() as req:
    # Seize a treatment resource when available
    treatment_cubicle = yield req

    # ALL CODE WHERE WE NEED TO KEEP HOLD OF THE RESOURCE

# CONTINUE AFTER RELEASING RESOURCE HERE
Original
def attend_clinic(self, patient):
    patient.arrival = self.env.now

    # request examination resource
    start_wait = self.env.now

    with self.treatment_cubicles.request() as req:
        # Seize a treatment resource when available
        yield req

        # record the waiting time for registration
        patient.wait_treat = self.env.now - start_wait

        # sample treatment duration
        patient.treat_duration = self.treat_dist.sample()
        yield self.env.timeout(patient.treat_duration)

    # total time in system
    patient.total_time = self.env.now - patient.arrival
With Vidigi Modifications
def attend_clinic(self, patient):
    patient.arrival = self.env.now

    # First, we log when the patient arrives  
    # The time will automatically be recorded as the current
    # simulation time
    self.logger.log_arrival( 
            entity_id=patient.identifier 
            ) 

    # request examination resource
    start_wait = self.env.now

    self.logger.log_queue(  
        entity_id=patient.identifier,  
        event="treatment_wait_begins"  
        )  

    # Seize a treatment resource when available
    with self.treatment_cubicles.request() as req:
        treatment_cubicle = yield req    

        # record the waiting time for registration
        patient.wait_treat = self.env.now - start_wait

        self.logger.log_resource_use_start(  
                entity_id=patient.identifier,  
                event="treatment_begins",  
                resource_id=treatment_cubicle.id_attribute  
                )  

        # sample treatment duration
        patient.treat_duration = self.treat_dist.sample()
        yield self.env.timeout(patient.treat_duration)

        self.logger.log_resource_use_end(  
            entity_id=patient.identifier,  
            event="treatment_complete",  
            resource_id=treatment_cubicle.id_attribute  
            )  

    # total time in system
    patient.total_time = self.env.now - patient.arrival

    # Finally, we record when the entity leaves the system  
    self.logger.log_departure(  
        entity_id=patient.identifier  
        )  

the calculate_run_results method

We can remove this method entirely from our new code - we can use the event log and vidigi helper functions to calculate all this information outside of the model, keeping our model code focussed on the modelling.

the run method

In this method, we can remove the code that runs the .calculate_run_results() method.

Original
def run(self):
    # Start up our DES entity generators that create new patients.  We've
    # only got one in this model, but we'd need to do this for each one if
    # we had multiple generators.
    self.env.process(self.generator_patient_arrivals())

    # Run the model for the duration specified in g class
    self.env.run(until=g.sim_duration)

    # Now the simulation run has finished, call the method that calculates
    # run results
    self.calculate_run_results()
With Vidigi Modifications
def run(self):
    # Start up our DES entity generators that create new patients.  We've
    # only got one in this model, but we'd need to do this for each one if
    # we had multiple generators.
    self.env.process(self.generator_patient_arrivals())

    # Run the model for the duration specified in g class
    self.env.run(until=g.sim_duration)

the Trial Class

In our trial class, we will no longer have any code relating to calculating the averages - we can do this all later from our event logs.

Instead, we will create some useful attributes that will be populated by run_trial(), and also set run_trial() to be automatically executed (though this is optional).

the init method

Original
def  __init__(self):
    self.df_trial_results = pd.DataFrame()
    self.df_trial_results["Run Number"] = [0]
    self.df_trial_results["Arrivals"] = [0]
    self.df_trial_results["Mean Queue Time Cubicle"] = [0.0]
    self.df_trial_results.set_index("Run Number", inplace=True)
With Vidigi Modifications
def  __init__(self):
        self.all_event_logs = [] 
        self.df_trial_results = pd.DataFrame() 

        self.run_trial() 

the run_trial method

Original
def run_trial(self):
    for run in range(1, g.number_of_runs+1):
        random.seed(run)

        my_model = Model(run)
        my_model.run()

        self.df_trial_results.loc[run] = [
            my_model.mean_q_time_cubicle
        ]

    return self.df_trial_results
With Vidigi Modifications
def run_trial(self):
    for run in range(1, g.number_of_runs+1):
        random.seed(run)

        my_model = Model(run)
        my_model.run()

        # For each run, we append the logger object (which is of class EventLogger)  
        # to our list all_event_logs, which started out empty  
        self.all_event_logs.append(my_model.logger) 

    # At the end, we create one large pandas dataframe of the results from every run
    self.df_trial_results = pd.concat(  
        [run_results.to_dataframe() for run_results in self.all_event_logs]  
        )  

Using vidigi to create an animation from our event log

For simple animations with vidigi, it is recommended that you use the animate_activity_log function.

This all-in-one function takes an event log of the structure discussed above, then turns it into an animated output that can be embedded in a quarto document, a web app, or saved as a standalone HTML file.

First, we need to create an instance of our trial class, then run the trial.

my_trial = Trial()

my_trial.run_trial()

The dataframe of event logs can then be viewed using my_trial.df_trial_results

The event_position_df

We can then generate our coordinates for the initial positioning of each step.

Note

The ‘event’ names must match the event names you assigned in the logging steps.

However, this will not be displayed anywhere in the final setup. Instead, use ‘label’ to define a human-readable label that can optionally be displayed in the final animation.

Warning

‘label’ should not be left out or be an empty string - both of these will cause problems.

Note

You only need to provide positions for

  • arrival
  • departure
  • queue
  • resource_use (optional - you can have an animation that is only queues)

i.e. you do not need to provide coordinates for resource_use_end

You can also opt to skip any queue or resource_use steps you do not want to show, though note that this could produce a misleading output if not carefully explained to end users

Tip

For queues and resource use, the coordinate will correspond to the bottom-right-hand corner of the block of queueing entities or resources.

event_position_df = pd.DataFrame([
                    {'event': 'arrival',
                     'x':  50, 'y': 300,
                     'label': "Arrival" },

                    # Triage - minor and trauma
                    {'event': 'treatment_wait_begins',
                     'x':  205, 'y': 275,
                     'label': "Waiting for Treatment"},

                    {'event': 'treatment_begins',
                     'x':  205, 'y': 175,
                     'resource':'n_cubicles',
                     'label': "Being Treated"},

                    {'event': 'depart',
                     'x':  270, 'y': 70,
                     'label': "Exit"}

                ])

Creating the animation

Finally, we can create the animation.

Warning

It is important that you only pass in a single run at a time!

Passing a dataframe in containing more than one run will produce incorrect animations.

You may, however, wish to give the user control over which run they visualise using a dropdown in something like Streamlit or Shiny - or perhaps

single_run_event_log_df = my_trial.df_trial_results[my_trial.df_trial_results['run_number']==1]

animate_activity_log(
        event_log=single_run_event_log_df,
        event_position_df= event_position_df,
        scenario=g(), # Use an instance of the g class as our scenario
        limit_duration=g.sim_duration,
        debug_mode=True, # Turn on logging messages
        setup_mode=True, # Turn on axis units - this can help with honing your event_position_df
        every_x_time_units=1,
        include_play_button=True,
        entity_icon_size=20,
        resource_icon_size=20,
        gap_between_entities=6,
        gap_between_entity_rows=25,
        plotly_height=700,
        frame_duration=200,
        plotly_width=1200,
        override_x_max=300,
        override_y_max=500,
        wrap_queues_at=25,
        step_snapshot_max=125,
        time_display_units="dhm",
        display_stage_labels=True # display our Label column from our event_position_df
    )
import random
import numpy as np
import pandas as pd
import simpy
from sim_tools.distributions import Exponential, Lognormal
from vidigi.resources import VidigiStore 
from vidigi.logging import EventLogger 
from vidigi.animation import animate_activity_log 

# Class to store global parameter values.  We don't create an instance of this
# class - we just refer to the class blueprint itself to access the numbers
# inside.
class g:
    '''
    Create a scenario to parameterise the simulation model

    Parameters:
    -----------
    random_number_set: int, optional (default=DEFAULT_RNG_SET)
        Set to control the initial seeds of each stream of pseudo
        random numbers used in the model.

    n_cubicles: int
        The number of treatment cubicles

    trauma_treat_mean: float
        Mean of the trauma cubicle treatment distribution (Lognormal)

    trauma_treat_var: float
        Variance of the trauma cubicle treatment distribution (Lognormal)

    arrival_rate: float
        Set the mean of the exponential distribution that is used to sample the
        inter-arrival time of patients

    sim_duration: int
        The number of time units the simulation will run for

    number_of_runs: int
        The number of times the simulation will be run with different random number streams

    '''
    random_number_set = 42

    n_cubicles = 3
    trauma_treat_mean = 40
    trauma_treat_var = 5

    arrival_rate = 5

    sim_duration = 600
    number_of_runs = 100

# Class representing patients coming in to the clinic.
class Patient:
    '''
    Class defining details for a patient entity
    '''
    def __init__(self, p_id):
        '''
        Constructor method

        Params:
        -----
        identifier: int
            a numeric identifier for the patient.
        '''
        self.identifier = p_id
        self.arrival = -np.inf
        self.wait_treat = -np.inf
        self.total_time = -np.inf
        self.treat_duration = -np.inf

# Class representing our model of the clinic.
class Model:
    '''
    Simulates the simplest minor treatment process for a patient

    1. Arrive
    2. Examined/treated by nurse when one available
    3. Discharged
    '''
    # Constructor to set up the model for a run.  We pass in a run number when
    # we create a new model.
    def __init__(self, run_number):
        # Create a SimPy environment in which everything will live
        self.env = simpy.Environment()

        # Store the passed in run number
        self.run_number = run_number

        # By passing in the env we've created, the logger will default to the simulation  
        # time when populating the time column of our event logs  
        # Passing the run number also ensures we can separate out different runs  
        # of the simulation in our later calculations 
        self.logger = EventLogger( 
            env=self.env,  
            run_number=self.run_number 
            ) 

        # Create a patient counter (which we'll use as a patient ID)
        self.patient_counter = 0

        # Create an empty list to store patient objects in
        self.patients = []

        # Create our resources
        self.init_resources()

        self.patient_inter_arrival_dist = Exponential(
            mean = g.arrival_rate,
            random_seed = self.run_number*g.random_number_set
            )

        self.treat_dist = Lognormal(
            mean = g.trauma_treat_mean,
            stdev = g.trauma_treat_var,
            random_seed = self.run_number*g.random_number_set
            )

    def init_resources(self):
        '''
        Init the number of resources
        and store in the arguments container object

        Resource list:
            1. Nurses/treatment bays (same thing in this model)

        '''
        self.treatment_cubicles = simpy.Store(self.env)

        self.treatment_cubicles = VidigiStore( 
                self.env, 
                num_resources=g.n_cubicles 
                ) 

    # A generator function that represents the DES generator for patient
    # arrivals
    def generator_patient_arrivals(self):
        # We use an infinite loop here to keep doing this indefinitely whilst
        # the simulation runs
        while True:
            # Increment the patient counter by 1 (this means our first patient
            # will have an ID of 1)
            self.patient_counter += 1

            # Create a new patient - an instance of the Patient Class we
            # defined above.  Remember, we pass in the ID when creating a
            # patient - so here we pass the patient counter to use as the ID.
            p = Patient(self.patient_counter)

            # Store patient in list for later easy access
            self.patients.append(p)

            # Tell SimPy to start up the attend_clinic generator function with
            # this patient (the generator function that will model the
            # patient's journey through the system)
            self.env.process(self.attend_clinic(p))

            # Randomly sample the time to the next patient arriving.  Here, we
            # sample from an exponential distribution (common for inter-arrival
            # times), and pass in a lambda value of 1 / mean.  The mean
            # inter-arrival time is stored in the g class.
            sampled_inter = self.patient_inter_arrival_dist.sample()

            # Freeze this instance of this function in place until the
            # inter-arrival time we sampled above has elapsed.  Note - time in
            # SimPy progresses in "Time Units", which can represent anything
            # you like (just make sure you're consistent within the model)
            yield self.env.timeout(sampled_inter)

    # A generator function that represents the pathway for a patient going
    # through the clinic.
    # The patient object is passed in to the generator function so we can
    # extract information from / record information to it
    def attend_clinic(self, patient):
        patient.arrival = self.env.now

        # First, we log when the patient arrives  
        # The time will automatically be recorded as the current
        # simulation time
        self.logger.log_arrival( 
                entity_id=patient.identifier 
                ) 

        # request examination resource
        start_wait = self.env.now

        self.logger.log_queue(  
            entity_id=patient.identifier,  
            event="treatment_wait_begins"  
            )  

        # Seize a treatment resource when available
        with self.treatment_cubicles.request() as req:
            treatment_cubicle = yield req    

            # record the waiting time for registration
            patient.wait_treat = self.env.now - start_wait

            self.logger.log_resource_use_start(  
                    entity_id=patient.identifier,  
                    event="treatment_begins",  
                    resource_id=treatment_cubicle.id_attribute  
                    )  

            # sample treatment duration
            patient.treat_duration = self.treat_dist.sample()
            yield self.env.timeout(patient.treat_duration)

            self.logger.log_resource_use_end(  
                entity_id=patient.identifier,  
                event="treatment_complete",  
                resource_id=treatment_cubicle.id_attribute  
                )  

        # total time in system
        patient.total_time = self.env.now - patient.arrival

        # Finally, we record when the entity leaves the system  
        self.logger.log_departure(  
            entity_id=patient.identifier  
            )  

    # The run method starts up the DES entity generators, runs the simulation,
    # and in turns calls anything we need to generate results for the run
    def run(self):
        # Start up our DES entity generators that create new patients.  We've
        # only got one in this model, but we'd need to do this for each one if
        # we had multiple generators.
        self.env.process(self.generator_patient_arrivals())

        # Run the model for the duration specified in g class
        self.env.run(until=g.sim_duration)

# Class representing a Trial for our simulation - a batch of simulation runs.
class Trial:
    # The constructor sets up a pandas dataframe that will store the key
    # results from each run against run number, with run number as the index.
    def  __init__(self):
        self.all_event_logs = [] 
        self.df_trial_results = pd.DataFrame() 

        self.run_trial() 

    # Method to run a trial
    def run_trial(self):
        for run in range(1, g.number_of_runs+1):
            random.seed(run)

            my_model = Model(run)
            my_model.run()

            # For each run, we append the logger object (which is of class EventLogger)  
            # to our list all_event_logs, which started out empty  
            self.all_event_logs.append(my_model.logger) 

        # At the end, we create one large pandas dataframe of the results from every run
        self.df_trial_results = pd.concat(  
            [run_results.to_dataframe() for run_results in self.all_event_logs]  
            )  
my_trial = Trial()

my_trial.run_trial()
my_trial.df_trial_results.head(10)
entity_id event_type event time pathway run_number timestamp resource_id
0 1 arrival_departure arrival 0.000000 None 1 None NaN
1 1 queue treatment_wait_begins 0.000000 None 1 None NaN
2 1 resource_use treatment_begins 0.000000 None 1 None 1.0
3 2 arrival_departure arrival 12.021043 None 1 None NaN
4 2 queue treatment_wait_begins 12.021043 None 1 None NaN
5 2 resource_use treatment_begins 12.021043 None 1 None 2.0
6 3 arrival_departure arrival 23.701991 None 1 None NaN
7 3 queue treatment_wait_begins 23.701991 None 1 None NaN
8 3 resource_use treatment_begins 23.701991 None 1 None 3.0
9 4 arrival_departure arrival 35.625796 None 1 None NaN
single_run_event_log_df = my_trial.df_trial_results[my_trial.df_trial_results['run_number']==1]

animate_activity_log(
        event_log=single_run_event_log_df,
        event_position_df= event_position_df,
        scenario=g(), # Use an instance of the g class as our scenario
        limit_duration=g.sim_duration,
        debug_mode=True, # Turn on logging messages
        setup_mode=True, # Turn on axis units - this can help with honing your event_position_df
        every_x_time_units=1,
        include_play_button=True,
        entity_icon_size=20,
        resource_icon_size=20,
        gap_between_entities=6,
        gap_between_queue_rows=25,
        plotly_height=700,
        frame_duration=200,
        plotly_width=1200,
        override_x_max=300,
        override_y_max=500,
        wrap_queues_at=25,
        step_snapshot_max=125,
        time_display_units="dhm",
        display_stage_labels=True # display our Label column from our event_position_df
    )
Animation function called at 17:20:35
Iteration through time-unit-by-time-unit logs complete 17:20:37
Snapshot df concatenation complete at 17:20:38
Reshaped animation dataframe finished construction at 17:20:38
Placement dataframe finished construction at 17:20:38
Output animation generation complete at 17:20:40
Total Time Elapsed: 5.92 seconds

When you have finished tweaking the layout, you can further enhance your output.

animate_activity_log(
        event_log=single_run_event_log_df,
        event_position_df= event_position_df,
        scenario=g(),
        limit_duration=g.sim_duration,
        debug_mode=False, # Turn off logging messages
        setup_mode=False, # Turn off axis units
        every_x_time_units=1,
        include_play_button=True,
        entity_icon_size=20,
        resource_icon_size=20,
        gap_between_entities=6,
        gap_between_queue_rows=25,
        plotly_height=700,
        frame_duration=200,
        plotly_width=1200,
        override_x_max=300,
        override_y_max=500,
        wrap_queues_at=25,
        step_snapshot_max=125,
        time_display_units="dhm",
        display_stage_labels=False, # hide our Label column from our event_position_df
        # Add a local or web-hosted image as our background
        add_background_image="https://raw.githubusercontent.com/Bergam0t/vidigi/refs/heads/main/examples/example_1_simplest_case/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png")

We can then rerun our animation, passing in different parameters - though make sure to rerun your trial if you do so!

Here, we will increase the number of cubicles from 3 to 7 and see the impact this has on the queue size.

g.n_cubicles = 7 

my_trial = Trial()

my_trial.run_trial()

single_run_event_log_df = my_trial.df_trial_results[my_trial.df_trial_results['run_number']==1]

animate_activity_log(
        event_log=single_run_event_log_df,
        event_position_df= event_position_df,
        scenario=g(),
        limit_duration=g.sim_duration,
        debug_mode=False, # Turn off logging messages
        setup_mode=False, # Turn off axis units
        every_x_time_units=1,
        include_play_button=True,
        entity_icon_size=20,
        resource_icon_size=20,
        gap_between_entities=6,
        gap_between_queue_rows=25,
        plotly_height=700,
        frame_duration=200,
        plotly_width=1200,
        override_x_max=300,
        override_y_max=500,
        wrap_queues_at=25,
        step_snapshot_max=125,
        time_display_units="dhm",
        display_stage_labels=False, # hide our Label column from our event_position_df
        # Add a local or web-hosted image as our background
        add_background_image="https://raw.githubusercontent.com/Bergam0t/vidigi/refs/heads/main/examples/example_1_simplest_case/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png")